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.
Files changed (104) hide show
  1. kiwimatecoder/__init__.py +8 -0
  2. kiwimatecoder/__main__.py +12 -0
  3. kiwimatecoder/acp/__init__.py +13 -0
  4. kiwimatecoder/acp/protocol.py +115 -0
  5. kiwimatecoder/acp/server.py +823 -0
  6. kiwimatecoder/acp/stdio.py +32 -0
  7. kiwimatecoder/agent.py +1162 -0
  8. kiwimatecoder/ai.py +68 -0
  9. kiwimatecoder/audit.py +65 -0
  10. kiwimatecoder/browser.py +261 -0
  11. kiwimatecoder/catalog.py +445 -0
  12. kiwimatecoder/checkpoints.py +103 -0
  13. kiwimatecoder/client.py +820 -0
  14. kiwimatecoder/clipboard.py +99 -0
  15. kiwimatecoder/commands.py +6757 -0
  16. kiwimatecoder/config.py +5335 -0
  17. kiwimatecoder/diagnostics.py +295 -0
  18. kiwimatecoder/documents.py +299 -0
  19. kiwimatecoder/evals/__init__.py +39 -0
  20. kiwimatecoder/evals/cases.py +230 -0
  21. kiwimatecoder/evals/render.py +46 -0
  22. kiwimatecoder/evals/runner.py +319 -0
  23. kiwimatecoder/events.py +95 -0
  24. kiwimatecoder/forge.py +172 -0
  25. kiwimatecoder/git.py +164 -0
  26. kiwimatecoder/headless.py +255 -0
  27. kiwimatecoder/hooks.py +170 -0
  28. kiwimatecoder/hunks.py +260 -0
  29. kiwimatecoder/i18n.py +368 -0
  30. kiwimatecoder/images.py +195 -0
  31. kiwimatecoder/index/__init__.py +14 -0
  32. kiwimatecoder/index/builder.py +376 -0
  33. kiwimatecoder/index/embeddings.py +151 -0
  34. kiwimatecoder/index/search.py +247 -0
  35. kiwimatecoder/index/store.py +251 -0
  36. kiwimatecoder/instructions.py +83 -0
  37. kiwimatecoder/jobs.py +594 -0
  38. kiwimatecoder/lsp/__init__.py +56 -0
  39. kiwimatecoder/lsp/client.py +548 -0
  40. kiwimatecoder/lsp/manager.py +353 -0
  41. kiwimatecoder/lsp/protocol.py +79 -0
  42. kiwimatecoder/main.py +4258 -0
  43. kiwimatecoder/mcp/__init__.py +45 -0
  44. kiwimatecoder/mcp/client.py +385 -0
  45. kiwimatecoder/mcp/errors.py +7 -0
  46. kiwimatecoder/mcp/manager.py +253 -0
  47. kiwimatecoder/mcp/protocol.py +132 -0
  48. kiwimatecoder/media.py +904 -0
  49. kiwimatecoder/memory.py +141 -0
  50. kiwimatecoder/network.py +112 -0
  51. kiwimatecoder/notify.py +139 -0
  52. kiwimatecoder/permissions.py +171 -0
  53. kiwimatecoder/pickers.py +417 -0
  54. kiwimatecoder/plugins.py +245 -0
  55. kiwimatecoder/pricing.py +167 -0
  56. kiwimatecoder/prompts.py +213 -0
  57. kiwimatecoder/providers.py +342 -0
  58. kiwimatecoder/redaction.py +66 -0
  59. kiwimatecoder/remote.py +294 -0
  60. kiwimatecoder/repl.py +1388 -0
  61. kiwimatecoder/routing.py +65 -0
  62. kiwimatecoder/sandbox.py +169 -0
  63. kiwimatecoder/sdk.py +146 -0
  64. kiwimatecoder/session.py +574 -0
  65. kiwimatecoder/share.py +386 -0
  66. kiwimatecoder/shell.py +660 -0
  67. kiwimatecoder/skills.py +138 -0
  68. kiwimatecoder/sync.py +513 -0
  69. kiwimatecoder/team.py +249 -0
  70. kiwimatecoder/telemetry.py +392 -0
  71. kiwimatecoder/templates.py +108 -0
  72. kiwimatecoder/tools/__init__.py +190 -0
  73. kiwimatecoder/tools/ask.py +62 -0
  74. kiwimatecoder/tools/base.py +69 -0
  75. kiwimatecoder/tools/browser.py +261 -0
  76. kiwimatecoder/tools/edit_file.py +143 -0
  77. kiwimatecoder/tools/forge.py +207 -0
  78. kiwimatecoder/tools/git.py +158 -0
  79. kiwimatecoder/tools/image.py +300 -0
  80. kiwimatecoder/tools/io.py +346 -0
  81. kiwimatecoder/tools/list_dir.py +67 -0
  82. kiwimatecoder/tools/lsp.py +254 -0
  83. kiwimatecoder/tools/memory.py +115 -0
  84. kiwimatecoder/tools/paths.py +234 -0
  85. kiwimatecoder/tools/read_file.py +113 -0
  86. kiwimatecoder/tools/registry.py +91 -0
  87. kiwimatecoder/tools/run_bash.py +189 -0
  88. kiwimatecoder/tools/search.py +224 -0
  89. kiwimatecoder/tools/selection.py +108 -0
  90. kiwimatecoder/tools/shell.py +158 -0
  91. kiwimatecoder/tools/skill.py +46 -0
  92. kiwimatecoder/tools/task.py +61 -0
  93. kiwimatecoder/tools/todo.py +77 -0
  94. kiwimatecoder/tools/web.py +146 -0
  95. kiwimatecoder/tools/write_file.py +83 -0
  96. kiwimatecoder/ui.py +189 -0
  97. kiwimatecoder/updater.py +502 -0
  98. kiwimatecoder/web.py +499 -0
  99. kiwimatecoder-0.1.0.dist-info/METADATA +2016 -0
  100. kiwimatecoder-0.1.0.dist-info/RECORD +104 -0
  101. kiwimatecoder-0.1.0.dist-info/WHEEL +5 -0
  102. kiwimatecoder-0.1.0.dist-info/entry_points.txt +2 -0
  103. kiwimatecoder-0.1.0.dist-info/licenses/LICENSE +21 -0
  104. 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
+ )