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,108 @@
|
|
|
1
|
+
"""Custom prompt templates runnable as REPL slash commands.
|
|
2
|
+
|
|
3
|
+
Users drop Markdown files named after the command into
|
|
4
|
+
``~/.kiwimatecoder/commands/`` or ``<workspace>/.kiwimatecoder/commands/``.
|
|
5
|
+
The file body is the prompt; ``$ARGUMENTS`` is replaced with whatever the user
|
|
6
|
+
typed after the command name. A workspace template wins over a user-level
|
|
7
|
+
template with the same name.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from kiwimatecoder import config
|
|
17
|
+
|
|
18
|
+
WORKSPACE_COMMANDS_DIR = Path(".kiwimatecoder") / "commands"
|
|
19
|
+
USER_COMMANDS_DIR_NAME = "commands"
|
|
20
|
+
MAX_TEMPLATE_BYTES = 16 * 1024
|
|
21
|
+
MAX_NAME_LENGTH = 40
|
|
22
|
+
ARGUMENTS_TOKEN = "$ARGUMENTS"
|
|
23
|
+
|
|
24
|
+
_NAME_PATTERN = re.compile(r"[a-z0-9][a-z0-9_-]*\Z")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class TemplateInfo:
|
|
29
|
+
"""One discovered prompt template."""
|
|
30
|
+
|
|
31
|
+
name: str
|
|
32
|
+
path: Path
|
|
33
|
+
description: str
|
|
34
|
+
body: str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _valid_name(name: str) -> bool:
|
|
38
|
+
return bool(name) and len(name) <= MAX_NAME_LENGTH and bool(_NAME_PATTERN.match(name))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _first_line(text: str, fallback: str) -> str:
|
|
42
|
+
for raw_line in text.splitlines():
|
|
43
|
+
line = raw_line.strip().lstrip("#").strip()
|
|
44
|
+
if line:
|
|
45
|
+
return line
|
|
46
|
+
return fallback
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _load_template(path: Path) -> TemplateInfo | None:
|
|
50
|
+
name = path.stem.strip().lower()
|
|
51
|
+
if not _valid_name(name):
|
|
52
|
+
return None
|
|
53
|
+
try:
|
|
54
|
+
raw = path.read_bytes()
|
|
55
|
+
except OSError:
|
|
56
|
+
return None
|
|
57
|
+
if b"\x00" in raw[:1024]:
|
|
58
|
+
return None
|
|
59
|
+
body = raw[:MAX_TEMPLATE_BYTES].decode("utf-8", "replace")
|
|
60
|
+
return TemplateInfo(
|
|
61
|
+
name=name,
|
|
62
|
+
path=path,
|
|
63
|
+
description=_first_line(body, name),
|
|
64
|
+
body=body,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _template_dirs(workspace_root: Path) -> list[Path]:
|
|
69
|
+
"""Workspace first so it wins name collisions with user templates."""
|
|
70
|
+
dirs = [workspace_root / WORKSPACE_COMMANDS_DIR]
|
|
71
|
+
try:
|
|
72
|
+
dirs.append(config.ensure_config_dir() / USER_COMMANDS_DIR_NAME)
|
|
73
|
+
except OSError:
|
|
74
|
+
# An unwritable home directory just means no user-level templates.
|
|
75
|
+
pass
|
|
76
|
+
return dirs
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def discover_templates(workspace_root: Path) -> dict[str, TemplateInfo]:
|
|
80
|
+
"""Return every valid template keyed by command name, sorted by name."""
|
|
81
|
+
found: dict[str, TemplateInfo] = {}
|
|
82
|
+
for directory in _template_dirs(workspace_root):
|
|
83
|
+
if not directory.is_dir():
|
|
84
|
+
continue
|
|
85
|
+
for path in sorted(directory.glob("*.md")):
|
|
86
|
+
template = _load_template(path)
|
|
87
|
+
if template is None:
|
|
88
|
+
continue
|
|
89
|
+
found.setdefault(template.name, template)
|
|
90
|
+
return dict(sorted(found.items()))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def find_template(name: str, workspace_root: Path) -> TemplateInfo | None:
|
|
94
|
+
"""Look up a template by command name; None when unknown or invalid."""
|
|
95
|
+
key = str(name).strip().lower()
|
|
96
|
+
if not key:
|
|
97
|
+
return None
|
|
98
|
+
return discover_templates(workspace_root).get(key)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def render_template(body: str, arguments: str) -> str:
|
|
102
|
+
"""Substitute ``$ARGUMENTS``; append the arguments when the body lacks it."""
|
|
103
|
+
text = arguments.strip()
|
|
104
|
+
if ARGUMENTS_TOKEN in body:
|
|
105
|
+
return body.replace(ARGUMENTS_TOKEN, text)
|
|
106
|
+
if text:
|
|
107
|
+
return f"{body}\n\nArguments: {text}"
|
|
108
|
+
return body
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Tool registry: schema export, dispatch, and approval previews.
|
|
2
|
+
|
|
3
|
+
A tool is "advertised" to the model via its JSON schema. In PLAN mode only
|
|
4
|
+
read-only tools are advertised (and the permission gate blocks the rest as a
|
|
5
|
+
second line of defense).
|
|
6
|
+
|
|
7
|
+
The mapping lives in :class:`~kiwimatecoder.tools.registry.ToolRegistry`, which
|
|
8
|
+
also records each tool's source (``"builtin"`` or a plugin label). The module
|
|
9
|
+
level ``TOOLS`` name is that registry and remains dict-compatible.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import TYPE_CHECKING, Any, Callable
|
|
15
|
+
|
|
16
|
+
from kiwimatecoder.tools.ask import ask_user_tool
|
|
17
|
+
from kiwimatecoder.tools.base import FunctionTool, ToolResult
|
|
18
|
+
from kiwimatecoder.tools.browser import browser_tool
|
|
19
|
+
from kiwimatecoder.tools.browser import preview as _browser_preview
|
|
20
|
+
from kiwimatecoder.tools.edit_file import edit_file_tool
|
|
21
|
+
from kiwimatecoder.tools.edit_file import preview as _edit_preview
|
|
22
|
+
from kiwimatecoder.tools.forge import forge_tool, forge_write_tool
|
|
23
|
+
from kiwimatecoder.tools.forge import preview as _forge_write_preview
|
|
24
|
+
from kiwimatecoder.tools.git import git_tool, git_write_tool
|
|
25
|
+
from kiwimatecoder.tools.git import preview as _git_write_preview
|
|
26
|
+
from kiwimatecoder.tools.image import (
|
|
27
|
+
generate_image_tool,
|
|
28
|
+
generate_video_tool,
|
|
29
|
+
view_image_tool,
|
|
30
|
+
)
|
|
31
|
+
from kiwimatecoder.tools.image import generate_image_preview as _media_preview
|
|
32
|
+
from kiwimatecoder.tools.image import generate_video_preview as _video_preview
|
|
33
|
+
from kiwimatecoder.tools.io import (
|
|
34
|
+
http_get_tool,
|
|
35
|
+
http_request_tool,
|
|
36
|
+
read_clipboard_tool,
|
|
37
|
+
write_clipboard_tool,
|
|
38
|
+
)
|
|
39
|
+
from kiwimatecoder.tools.io import http_request_preview as _http_request_preview
|
|
40
|
+
from kiwimatecoder.tools.io import write_clipboard_preview as _clipboard_write_preview
|
|
41
|
+
from kiwimatecoder.tools.list_dir import list_dir_tool
|
|
42
|
+
from kiwimatecoder.tools.lsp import (
|
|
43
|
+
lsp_definition_tool,
|
|
44
|
+
lsp_diagnostics_tool,
|
|
45
|
+
lsp_references_tool,
|
|
46
|
+
)
|
|
47
|
+
from kiwimatecoder.tools.memory import preview as _remember_preview
|
|
48
|
+
from kiwimatecoder.tools.memory import recall_tool, remember_tool
|
|
49
|
+
from kiwimatecoder.tools.read_file import read_file_tool
|
|
50
|
+
from kiwimatecoder.tools.registry import (
|
|
51
|
+
BUILTIN_SOURCE,
|
|
52
|
+
PLUGIN_SOURCE,
|
|
53
|
+
ToolRegistry,
|
|
54
|
+
)
|
|
55
|
+
from kiwimatecoder.tools.run_bash import preview as _bash_preview
|
|
56
|
+
from kiwimatecoder.tools.run_bash import run_bash_tool
|
|
57
|
+
from kiwimatecoder.tools.search import search_tool
|
|
58
|
+
from kiwimatecoder.tools.selection import select_hunks as select_hunks
|
|
59
|
+
from kiwimatecoder.tools.shell import jobs_preview as _shell_jobs_preview
|
|
60
|
+
from kiwimatecoder.tools.shell import preview as _shell_preview
|
|
61
|
+
from kiwimatecoder.tools.shell import shell_jobs_tool, shell_tool
|
|
62
|
+
from kiwimatecoder.tools.skill import load_skill_tool
|
|
63
|
+
from kiwimatecoder.tools.task import task_tool
|
|
64
|
+
from kiwimatecoder.tools.todo import update_todos_tool
|
|
65
|
+
from kiwimatecoder.tools.web import web_fetch_tool, web_search_tool
|
|
66
|
+
from kiwimatecoder.tools.write_file import preview as _write_preview
|
|
67
|
+
from kiwimatecoder.tools.write_file import write_file_tool
|
|
68
|
+
|
|
69
|
+
if TYPE_CHECKING:
|
|
70
|
+
from kiwimatecoder.session import Session
|
|
71
|
+
|
|
72
|
+
_ALL_TOOLS: list[FunctionTool] = [
|
|
73
|
+
read_file_tool,
|
|
74
|
+
view_image_tool,
|
|
75
|
+
generate_image_tool,
|
|
76
|
+
generate_video_tool,
|
|
77
|
+
list_dir_tool,
|
|
78
|
+
search_tool,
|
|
79
|
+
load_skill_tool,
|
|
80
|
+
write_file_tool,
|
|
81
|
+
edit_file_tool,
|
|
82
|
+
run_bash_tool,
|
|
83
|
+
shell_tool,
|
|
84
|
+
shell_jobs_tool,
|
|
85
|
+
update_todos_tool,
|
|
86
|
+
task_tool,
|
|
87
|
+
ask_user_tool,
|
|
88
|
+
web_fetch_tool,
|
|
89
|
+
web_search_tool,
|
|
90
|
+
git_tool,
|
|
91
|
+
git_write_tool,
|
|
92
|
+
forge_tool,
|
|
93
|
+
forge_write_tool,
|
|
94
|
+
remember_tool,
|
|
95
|
+
recall_tool,
|
|
96
|
+
read_clipboard_tool,
|
|
97
|
+
write_clipboard_tool,
|
|
98
|
+
http_get_tool,
|
|
99
|
+
http_request_tool,
|
|
100
|
+
lsp_diagnostics_tool,
|
|
101
|
+
lsp_definition_tool,
|
|
102
|
+
lsp_references_tool,
|
|
103
|
+
browser_tool,
|
|
104
|
+
]
|
|
105
|
+
|
|
106
|
+
TOOLS = ToolRegistry()
|
|
107
|
+
for _tool in _ALL_TOOLS:
|
|
108
|
+
TOOLS.register(_tool, source=BUILTIN_SOURCE)
|
|
109
|
+
|
|
110
|
+
# Preview functions used by the permission gate to render what an action will do.
|
|
111
|
+
_PREVIEWS: dict[str, Callable[[dict[str, Any], Any], str]] = {
|
|
112
|
+
"write_file": _write_preview,
|
|
113
|
+
"edit_file": _edit_preview,
|
|
114
|
+
"run_bash": _bash_preview,
|
|
115
|
+
"shell": _shell_preview,
|
|
116
|
+
"shell_jobs": _shell_jobs_preview,
|
|
117
|
+
"git_write": _git_write_preview,
|
|
118
|
+
"forge_write": _forge_write_preview,
|
|
119
|
+
"remember": _remember_preview,
|
|
120
|
+
"write_clipboard": _clipboard_write_preview,
|
|
121
|
+
"http_request": _http_request_preview,
|
|
122
|
+
"browser": _browser_preview,
|
|
123
|
+
"generate_image": _media_preview,
|
|
124
|
+
"generate_video": _video_preview,
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def register_tool(tool: FunctionTool, source: str = PLUGIN_SOURCE) -> FunctionTool:
|
|
129
|
+
"""Register a tool dynamically and return it.
|
|
130
|
+
|
|
131
|
+
Raises ``ValueError`` when the name is already registered; pass through
|
|
132
|
+
``TOOLS.register(tool, replace=True)`` to deliberately replace one.
|
|
133
|
+
"""
|
|
134
|
+
return TOOLS.register(tool, source=source)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def unregister_tool(name: str, force: bool = False) -> bool:
|
|
138
|
+
"""Remove a dynamically registered tool; built-ins need ``force=True``."""
|
|
139
|
+
return TOOLS.unregister(name, force=force)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def tool_sources() -> dict[str, list[str]]:
|
|
143
|
+
"""Map each tool source to its sorted tool names."""
|
|
144
|
+
return TOOLS.sources()
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def read_only_tools() -> list[FunctionTool]:
|
|
148
|
+
return TOOLS.read_only()
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def tool_schemas(read_only: bool = False) -> list[dict[str, Any]]:
|
|
152
|
+
"""Return OpenAI tool schemas, optionally restricted to read-only tools."""
|
|
153
|
+
return TOOLS.schemas(read_only=read_only)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def get_tool(name: str) -> FunctionTool | None:
|
|
157
|
+
return TOOLS.get(name)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def preview(name: str, args: dict[str, Any], session: Session) -> str | None:
|
|
161
|
+
"""Return a human-readable preview of a tool action, or None if not previewable."""
|
|
162
|
+
fn = _PREVIEWS.get(name)
|
|
163
|
+
return fn(args, session) if fn else None
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def dispatch(name: str, args: dict[str, Any], session: Session) -> ToolResult:
|
|
167
|
+
"""Execute a tool by name."""
|
|
168
|
+
tool = TOOLS.get(name)
|
|
169
|
+
if tool is None:
|
|
170
|
+
return ToolResult.error(f"Unknown tool: {name}")
|
|
171
|
+
return tool.execute(args, session)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
__all__ = [
|
|
175
|
+
"BUILTIN_SOURCE",
|
|
176
|
+
"PLUGIN_SOURCE",
|
|
177
|
+
"TOOLS",
|
|
178
|
+
"FunctionTool",
|
|
179
|
+
"ToolRegistry",
|
|
180
|
+
"ToolResult",
|
|
181
|
+
"dispatch",
|
|
182
|
+
"get_tool",
|
|
183
|
+
"preview",
|
|
184
|
+
"read_only_tools",
|
|
185
|
+
"register_tool",
|
|
186
|
+
"select_hunks",
|
|
187
|
+
"tool_schemas",
|
|
188
|
+
"tool_sources",
|
|
189
|
+
"unregister_tool",
|
|
190
|
+
]
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""ask_user tool: ask the user a clarifying question mid-turn."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from kiwimatecoder.session import Session
|
|
8
|
+
from kiwimatecoder.tools.base import FunctionTool, ToolResult
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _ask_user(args: dict[str, Any], session: Session) -> ToolResult:
|
|
12
|
+
question = str(args.get("question") or "").strip()
|
|
13
|
+
if not question:
|
|
14
|
+
return ToolResult.error("'question' is required")
|
|
15
|
+
|
|
16
|
+
raw_options = args.get("options")
|
|
17
|
+
options = (
|
|
18
|
+
[str(option).strip() for option in raw_options if str(option).strip()]
|
|
19
|
+
if isinstance(raw_options, list)
|
|
20
|
+
else []
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
callback = getattr(session, "ask_user", None)
|
|
24
|
+
if callback is None:
|
|
25
|
+
return ToolResult.error(
|
|
26
|
+
"No interactive user is available to answer; make a reasonable "
|
|
27
|
+
"assumption and state it."
|
|
28
|
+
)
|
|
29
|
+
try:
|
|
30
|
+
answer = callback(question, options)
|
|
31
|
+
except (EOFError, KeyboardInterrupt):
|
|
32
|
+
return ToolResult.error("The user did not answer.")
|
|
33
|
+
answer = str(answer or "").strip()
|
|
34
|
+
if not answer:
|
|
35
|
+
return ToolResult.error("The user gave an empty answer.")
|
|
36
|
+
return ToolResult(content=f"User answer: {answer}")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
ask_user_tool = FunctionTool(
|
|
40
|
+
name="ask_user",
|
|
41
|
+
description=(
|
|
42
|
+
"Ask the user a clarifying question when a decision cannot be inferred "
|
|
43
|
+
"from the workspace. Provide 2-4 concrete options when possible. Use "
|
|
44
|
+
"sparingly: prefer reading the relevant files first."
|
|
45
|
+
),
|
|
46
|
+
parameters={
|
|
47
|
+
"type": "object",
|
|
48
|
+
"properties": {
|
|
49
|
+
"question": {
|
|
50
|
+
"type": "string",
|
|
51
|
+
"description": "The question to ask.",
|
|
52
|
+
},
|
|
53
|
+
"options": {
|
|
54
|
+
"type": "array",
|
|
55
|
+
"description": "Optional suggested answers.",
|
|
56
|
+
"items": {"type": "string"},
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
"required": ["question"],
|
|
60
|
+
},
|
|
61
|
+
func=_ask_user,
|
|
62
|
+
)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Core types shared by all tools."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Callable, Protocol
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SessionProtocol(Protocol):
|
|
11
|
+
"""Protocol representing the session interface required by tools."""
|
|
12
|
+
|
|
13
|
+
workspace_root: Path
|
|
14
|
+
|
|
15
|
+
def record_touched(self, path: str) -> None: ...
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class ToolResult:
|
|
20
|
+
"""The outcome of running a tool."""
|
|
21
|
+
|
|
22
|
+
content: str
|
|
23
|
+
ok: bool = True
|
|
24
|
+
|
|
25
|
+
@classmethod
|
|
26
|
+
def error(cls, message: str) -> "ToolResult":
|
|
27
|
+
return cls(content=f"Error: {message}", ok=False)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Tool(Protocol):
|
|
31
|
+
"""Protocol every tool implements."""
|
|
32
|
+
|
|
33
|
+
name: str
|
|
34
|
+
description: str
|
|
35
|
+
parameters: dict[str, Any] # JSON Schema for the function arguments
|
|
36
|
+
writes: bool # mutates the filesystem
|
|
37
|
+
runs: bool # executes commands
|
|
38
|
+
|
|
39
|
+
def execute(self, args: dict[str, Any], session: Any) -> ToolResult: ...
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class FunctionTool:
|
|
44
|
+
"""Concrete tool backed by a plain callable."""
|
|
45
|
+
|
|
46
|
+
name: str
|
|
47
|
+
description: str
|
|
48
|
+
parameters: dict[str, Any]
|
|
49
|
+
func: Callable[[dict[str, Any], Any], ToolResult]
|
|
50
|
+
writes: bool = False
|
|
51
|
+
runs: bool = False
|
|
52
|
+
|
|
53
|
+
def execute(self, args: dict[str, Any], session: Any) -> ToolResult:
|
|
54
|
+
return self.func(args, session)
|
|
55
|
+
|
|
56
|
+
def schema(self) -> dict[str, Any]:
|
|
57
|
+
"""Return the OpenAI tool/function schema for this tool."""
|
|
58
|
+
return {
|
|
59
|
+
"type": "function",
|
|
60
|
+
"function": {
|
|
61
|
+
"name": self.name,
|
|
62
|
+
"description": self.description,
|
|
63
|
+
"parameters": self.parameters,
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def needs_approval(self) -> bool:
|
|
69
|
+
return self.writes or self.runs
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""browser tool: drive an optional headless Chromium page via Playwright.
|
|
2
|
+
|
|
3
|
+
The tool is approval-gated (``runs=True``) and only advertised while browser
|
|
4
|
+
automation is enabled in config. It never imports Playwright itself; the
|
|
5
|
+
optional driver lives in :mod:`kiwimatecoder.browser`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from kiwimatecoder import browser, config, images, redaction
|
|
14
|
+
from kiwimatecoder import web as web_module
|
|
15
|
+
from kiwimatecoder.session import Session
|
|
16
|
+
from kiwimatecoder.tools.base import FunctionTool, ToolResult
|
|
17
|
+
from kiwimatecoder.tools.paths import display_path
|
|
18
|
+
|
|
19
|
+
ACTIONS = ("open", "text", "screenshot", "click", "type", "evaluate", "close")
|
|
20
|
+
|
|
21
|
+
TEXT_MAX_CHARS = 30_000
|
|
22
|
+
EVALUATE_MAX_CHARS = 10_000
|
|
23
|
+
MIN_TIMEOUT_MS = 1_000
|
|
24
|
+
MAX_TIMEOUT_MS = 120_000
|
|
25
|
+
|
|
26
|
+
DISABLED_MESSAGE = (
|
|
27
|
+
"Browser automation is disabled. Enable it with `/config browser enable on` "
|
|
28
|
+
"(and install the optional extra: pip install 'kiwimatecoder[browser]' && "
|
|
29
|
+
"playwright install chromium)."
|
|
30
|
+
)
|
|
31
|
+
NO_PAGE_MESSAGE = "No page is open. Use action='open' with a URL first."
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _timeout_override(args: dict[str, Any]) -> tuple[int | None, str | None]:
|
|
35
|
+
raw = args.get("timeout_ms")
|
|
36
|
+
if raw is None:
|
|
37
|
+
return None, None
|
|
38
|
+
try:
|
|
39
|
+
value = int(raw)
|
|
40
|
+
except (TypeError, ValueError):
|
|
41
|
+
return None, "'timeout_ms' must be an integer number of milliseconds"
|
|
42
|
+
if not MIN_TIMEOUT_MS <= value <= MAX_TIMEOUT_MS:
|
|
43
|
+
return None, (
|
|
44
|
+
f"'timeout_ms' must be between {MIN_TIMEOUT_MS} and {MAX_TIMEOUT_MS}"
|
|
45
|
+
)
|
|
46
|
+
return value, None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _validate_url(url: str) -> str:
|
|
50
|
+
try:
|
|
51
|
+
cleaned = web_module.clean_url(url)
|
|
52
|
+
except web_module.WebError as exc:
|
|
53
|
+
raise browser.BrowserError(str(exc)) from exc
|
|
54
|
+
if web_module.is_local_address(cleaned) and not config.get_web()["allow_local"]:
|
|
55
|
+
raise browser.BrowserError(
|
|
56
|
+
"Refusing to open a local or private address. Enable it with "
|
|
57
|
+
"/config web allow-local on (or config web allow-local on)."
|
|
58
|
+
)
|
|
59
|
+
return cleaned
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _required(args: dict[str, Any], key: str) -> tuple[str, str | None]:
|
|
63
|
+
value = str(args.get(key) or "").strip()
|
|
64
|
+
if not value:
|
|
65
|
+
return "", f"'{key}' is required"
|
|
66
|
+
return value, None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _apply_timeout(driver: browser.Driver, timeout_ms: int) -> None:
|
|
70
|
+
setter = getattr(driver, "set_timeout", None)
|
|
71
|
+
if callable(setter):
|
|
72
|
+
setter(timeout_ms)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _format_result(value: Any) -> str:
|
|
76
|
+
if isinstance(value, str):
|
|
77
|
+
rendered = value
|
|
78
|
+
else:
|
|
79
|
+
try:
|
|
80
|
+
rendered = json.dumps(value, indent=2, default=str)
|
|
81
|
+
except (TypeError, ValueError):
|
|
82
|
+
rendered = repr(value)
|
|
83
|
+
if len(rendered) > EVALUATE_MAX_CHARS:
|
|
84
|
+
rendered = rendered[:EVALUATE_MAX_CHARS] + (
|
|
85
|
+
f"\n\n[truncated at {EVALUATE_MAX_CHARS} characters]"
|
|
86
|
+
)
|
|
87
|
+
return rendered or "(no result)"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _screenshot(driver: browser.Driver, session: Session) -> ToolResult:
|
|
91
|
+
path = browser.screenshot_path(session.workspace_root)
|
|
92
|
+
driver.screenshot(path)
|
|
93
|
+
label = display_path(path, session.workspace_root)
|
|
94
|
+
|
|
95
|
+
vision = config.get_vision()
|
|
96
|
+
limit = int(vision["max_images_per_turn"])
|
|
97
|
+
if len(session.pending_images) >= limit:
|
|
98
|
+
return ToolResult(
|
|
99
|
+
content=(
|
|
100
|
+
f"Saved screenshot to {label}; the per-turn image limit "
|
|
101
|
+
f"({limit}) is reached, so it was not attached."
|
|
102
|
+
)
|
|
103
|
+
)
|
|
104
|
+
try:
|
|
105
|
+
entry = images.encode_image(path, int(vision["max_image_bytes"]))
|
|
106
|
+
except (ValueError, OSError) as exc:
|
|
107
|
+
return ToolResult(
|
|
108
|
+
content=f"Saved screenshot to {label} but could not attach it: {exc}"
|
|
109
|
+
)
|
|
110
|
+
session.pending_images.append(entry)
|
|
111
|
+
return ToolResult(content=f"Saved screenshot to {label} and attached it.")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _browser(args: dict[str, Any], session: Session) -> ToolResult:
|
|
115
|
+
action = str(args.get("action") or "").strip().lower()
|
|
116
|
+
if action not in ACTIONS:
|
|
117
|
+
return ToolResult.error(
|
|
118
|
+
f"Unknown browser action '{action or '?'}'. "
|
|
119
|
+
f"Choose: {', '.join(ACTIONS)}."
|
|
120
|
+
)
|
|
121
|
+
settings = config.get_browser()
|
|
122
|
+
if not settings["enabled"]:
|
|
123
|
+
return ToolResult.error(DISABLED_MESSAGE)
|
|
124
|
+
|
|
125
|
+
timeout_ms, error = _timeout_override(args)
|
|
126
|
+
if error:
|
|
127
|
+
return ToolResult.error(error)
|
|
128
|
+
|
|
129
|
+
if action == "close":
|
|
130
|
+
browser.reset_driver()
|
|
131
|
+
return ToolResult(content="Browser closed.")
|
|
132
|
+
|
|
133
|
+
if action == "open":
|
|
134
|
+
url = str(args.get("url") or "").strip()
|
|
135
|
+
if not url:
|
|
136
|
+
return ToolResult.error("'url' is required for action 'open'")
|
|
137
|
+
try:
|
|
138
|
+
cleaned = _validate_url(url)
|
|
139
|
+
driver = browser.get_driver(settings)
|
|
140
|
+
if timeout_ms is not None:
|
|
141
|
+
_apply_timeout(driver, timeout_ms)
|
|
142
|
+
final_url, title = driver.open(cleaned)
|
|
143
|
+
except browser.BrowserError as exc:
|
|
144
|
+
return ToolResult.error(str(exc))
|
|
145
|
+
return ToolResult(
|
|
146
|
+
content=f"Opened {final_url}\nTitle: {title or '(no title)'}"
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
page = browser.current_driver()
|
|
150
|
+
if page is None:
|
|
151
|
+
return ToolResult.error(NO_PAGE_MESSAGE)
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
if timeout_ms is not None:
|
|
155
|
+
_apply_timeout(page, timeout_ms)
|
|
156
|
+
|
|
157
|
+
if action == "text":
|
|
158
|
+
page_text = page.text()
|
|
159
|
+
if len(page_text) > TEXT_MAX_CHARS:
|
|
160
|
+
page_text = page_text[:TEXT_MAX_CHARS] + (
|
|
161
|
+
f"\n\n[truncated at {TEXT_MAX_CHARS} characters]"
|
|
162
|
+
)
|
|
163
|
+
return ToolResult(content=page_text or "(no visible text)")
|
|
164
|
+
|
|
165
|
+
if action == "screenshot":
|
|
166
|
+
return _screenshot(page, session)
|
|
167
|
+
|
|
168
|
+
if action == "click":
|
|
169
|
+
selector, error = _required(args, "selector")
|
|
170
|
+
if error:
|
|
171
|
+
return ToolResult.error(error)
|
|
172
|
+
page.click(selector)
|
|
173
|
+
return ToolResult(content=f"Clicked {selector}.")
|
|
174
|
+
|
|
175
|
+
if action == "type":
|
|
176
|
+
selector, error = _required(args, "selector")
|
|
177
|
+
if error:
|
|
178
|
+
return ToolResult.error(error)
|
|
179
|
+
text = str(args.get("text") or "")
|
|
180
|
+
if not text:
|
|
181
|
+
return ToolResult.error("'text' is required for action 'type'")
|
|
182
|
+
page.type(selector, text)
|
|
183
|
+
return ToolResult(content=f"Typed into {selector}.")
|
|
184
|
+
|
|
185
|
+
if action == "evaluate":
|
|
186
|
+
script, error = _required(args, "script")
|
|
187
|
+
if error:
|
|
188
|
+
return ToolResult.error(error)
|
|
189
|
+
return ToolResult(content=_format_result(page.evaluate(script)))
|
|
190
|
+
except browser.BrowserError as exc:
|
|
191
|
+
return ToolResult.error(str(exc))
|
|
192
|
+
|
|
193
|
+
return ToolResult.error(f"Unsupported browser action '{action}'.")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def preview(args: dict[str, Any], session: Session) -> str:
|
|
197
|
+
"""Render the approval preview: ``browser <action> <target>``."""
|
|
198
|
+
del session
|
|
199
|
+
action = str(args.get("action") or "?")
|
|
200
|
+
target = (
|
|
201
|
+
args.get("url")
|
|
202
|
+
or args.get("selector")
|
|
203
|
+
or args.get("script")
|
|
204
|
+
or args.get("text")
|
|
205
|
+
or ""
|
|
206
|
+
)
|
|
207
|
+
cleaned = redaction.redact(str(target))
|
|
208
|
+
if len(cleaned) > 80:
|
|
209
|
+
cleaned = cleaned[:77] + "..."
|
|
210
|
+
return f"browser {action} {cleaned}".rstrip()
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
browser_tool = FunctionTool(
|
|
214
|
+
name="browser",
|
|
215
|
+
description=(
|
|
216
|
+
"Drive a headless Chromium page when browser automation is enabled. "
|
|
217
|
+
"Start with action='open' and a URL, then read the page with 'text', "
|
|
218
|
+
"capture it with 'screenshot' (the image is sent to you), interact "
|
|
219
|
+
"with 'click'/'type', run JavaScript with 'evaluate', or finish with "
|
|
220
|
+
"'close'. Local and private addresses are blocked unless enabled in "
|
|
221
|
+
"/config web. Requires the optional Playwright extra."
|
|
222
|
+
),
|
|
223
|
+
parameters={
|
|
224
|
+
"type": "object",
|
|
225
|
+
"properties": {
|
|
226
|
+
"action": {
|
|
227
|
+
"type": "string",
|
|
228
|
+
"enum": list(ACTIONS),
|
|
229
|
+
"description": "Browser action to perform.",
|
|
230
|
+
},
|
|
231
|
+
"url": {
|
|
232
|
+
"type": "string",
|
|
233
|
+
"description": "Absolute http(s) URL for action 'open'.",
|
|
234
|
+
},
|
|
235
|
+
"selector": {
|
|
236
|
+
"type": "string",
|
|
237
|
+
"description": "CSS selector for 'click' or 'type'.",
|
|
238
|
+
},
|
|
239
|
+
"text": {
|
|
240
|
+
"type": "string",
|
|
241
|
+
"description": "Text to enter for action 'type'.",
|
|
242
|
+
},
|
|
243
|
+
"script": {
|
|
244
|
+
"type": "string",
|
|
245
|
+
"description": "JavaScript expression for action 'evaluate'.",
|
|
246
|
+
},
|
|
247
|
+
"timeout_ms": {
|
|
248
|
+
"type": "integer",
|
|
249
|
+
"description": (
|
|
250
|
+
"Optional per-action timeout in milliseconds "
|
|
251
|
+
f"({MIN_TIMEOUT_MS}-{MAX_TIMEOUT_MS}); defaults to the "
|
|
252
|
+
"configured browser timeout."
|
|
253
|
+
),
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
"required": ["action"],
|
|
257
|
+
},
|
|
258
|
+
func=_browser,
|
|
259
|
+
writes=False,
|
|
260
|
+
runs=True,
|
|
261
|
+
)
|