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,300 @@
|
|
|
1
|
+
"""Image tools: attach an image to the conversation, or generate media.
|
|
2
|
+
|
|
3
|
+
``view_image`` attaches an existing workspace file (read-only). The
|
|
4
|
+
``generate_image`` and ``generate_video`` tools call the configured media
|
|
5
|
+
provider, which costs money, so they are approval-gated (``runs=True``) and
|
|
6
|
+
refuse with enable guidance while media generation is turned off.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import base64
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from kiwimatecoder import config, images, media
|
|
15
|
+
from kiwimatecoder.redaction import redact
|
|
16
|
+
from kiwimatecoder.session import Session
|
|
17
|
+
from kiwimatecoder.tools.base import FunctionTool, ToolResult
|
|
18
|
+
from kiwimatecoder.tools.paths import PathError, display_path, resolve_for_read
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _view_image(args: dict[str, Any], session: Session) -> ToolResult:
|
|
22
|
+
path = str(args.get("path") or "").strip()
|
|
23
|
+
if not path:
|
|
24
|
+
return ToolResult.error("'path' is required")
|
|
25
|
+
try:
|
|
26
|
+
resolved = resolve_for_read(
|
|
27
|
+
path,
|
|
28
|
+
session.workspace_root,
|
|
29
|
+
trusted=bool(getattr(session, "trusted_workspace", False)),
|
|
30
|
+
)
|
|
31
|
+
except PathError as exc:
|
|
32
|
+
return ToolResult.error(str(exc))
|
|
33
|
+
if not resolved.is_file():
|
|
34
|
+
return ToolResult.error(f"File not found: {path}")
|
|
35
|
+
|
|
36
|
+
vision = config.get_vision()
|
|
37
|
+
limit = int(vision["max_images_per_turn"])
|
|
38
|
+
if len(session.pending_images) >= limit:
|
|
39
|
+
return ToolResult.error(
|
|
40
|
+
f"Image limit reached ({limit} per turn). Send these images first, "
|
|
41
|
+
"then attach more in the next message."
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
entry = images.encode_image(resolved, int(vision["max_image_bytes"]))
|
|
46
|
+
except (ValueError, OSError) as exc:
|
|
47
|
+
return ToolResult.error(str(exc))
|
|
48
|
+
|
|
49
|
+
session.pending_images.append(entry)
|
|
50
|
+
dimensions = images.image_dimensions(base64.b64decode(entry["data"]))
|
|
51
|
+
label = f"{path} ({dimensions[0]}x{dimensions[1]})" if dimensions else path
|
|
52
|
+
return ToolResult(content=f"Attached {label}")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
view_image_tool = FunctionTool(
|
|
56
|
+
name="view_image",
|
|
57
|
+
description=(
|
|
58
|
+
"Attach an image file (.png, .jpg, .jpeg, .gif, .webp) from the "
|
|
59
|
+
"workspace so the model can see it. Read-only; the image is sent with "
|
|
60
|
+
"the next request. Use it for screenshots, diagrams, and design mockups."
|
|
61
|
+
),
|
|
62
|
+
parameters={
|
|
63
|
+
"type": "object",
|
|
64
|
+
"properties": {
|
|
65
|
+
"path": {
|
|
66
|
+
"type": "string",
|
|
67
|
+
"description": "Path to the image, relative to the workspace root.",
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
"required": ["path"],
|
|
71
|
+
},
|
|
72
|
+
func=_view_image,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _generate_image(args: dict[str, Any], session: Session) -> ToolResult:
|
|
77
|
+
if not config.get_media()["enabled"]:
|
|
78
|
+
return ToolResult.error(
|
|
79
|
+
"Image generation is disabled. Enable it with "
|
|
80
|
+
"`/config media enable on` (or `config media enable on`)."
|
|
81
|
+
)
|
|
82
|
+
prompt = str(args.get("prompt") or "").strip()
|
|
83
|
+
if not prompt:
|
|
84
|
+
return ToolResult.error("'prompt' is required")
|
|
85
|
+
size = args.get("size")
|
|
86
|
+
model = args.get("model")
|
|
87
|
+
try:
|
|
88
|
+
result = media.generate_image(
|
|
89
|
+
prompt,
|
|
90
|
+
session=session,
|
|
91
|
+
size=str(size).strip() if size else None,
|
|
92
|
+
model=str(model).strip() if model else None,
|
|
93
|
+
)
|
|
94
|
+
except media.MediaError as exc:
|
|
95
|
+
return ToolResult.error(str(exc))
|
|
96
|
+
note = media.attach_result(result, session)
|
|
97
|
+
location = display_path(result.path, session.workspace_root)
|
|
98
|
+
content = (
|
|
99
|
+
f"Generated image saved to {location} ({result.bytes:,} bytes, "
|
|
100
|
+
f"model {result.model})"
|
|
101
|
+
)
|
|
102
|
+
if note:
|
|
103
|
+
content += f". {note}"
|
|
104
|
+
return ToolResult(content=content)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def generate_image_preview(args: dict[str, Any], session: Session) -> str:
|
|
108
|
+
"""Render the approval preview: provider, model, size, and prompt."""
|
|
109
|
+
del session
|
|
110
|
+
settings = config.get_media()
|
|
111
|
+
prompt = redact(str(args.get("prompt") or "")).strip()
|
|
112
|
+
if len(prompt) > 300:
|
|
113
|
+
prompt = prompt[:297] + "..."
|
|
114
|
+
model = str(args.get("model") or settings["model"]).strip() or "(none chosen)"
|
|
115
|
+
size = str(args.get("size") or settings["size"]).strip()
|
|
116
|
+
return (
|
|
117
|
+
f"Provider: {settings['provider']}\n"
|
|
118
|
+
f"Model: {model}\n"
|
|
119
|
+
f"Size: {size}\n"
|
|
120
|
+
f"Output: {settings['output_dir']}\n"
|
|
121
|
+
f"Prompt: {prompt or '(missing)'}\n\n"
|
|
122
|
+
"This calls a paid image API."
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
generate_image_tool = FunctionTool(
|
|
127
|
+
name="generate_image",
|
|
128
|
+
description=(
|
|
129
|
+
"Generate an image from a text prompt through the configured media "
|
|
130
|
+
"provider and attach it to the conversation. Costs money and requires "
|
|
131
|
+
"media generation to be enabled (`/config media enable on`) and an "
|
|
132
|
+
"image model to be chosen (`/image model`). Use it for mockups, icons, "
|
|
133
|
+
"diagrams, and illustrations."
|
|
134
|
+
),
|
|
135
|
+
parameters={
|
|
136
|
+
"type": "object",
|
|
137
|
+
"properties": {
|
|
138
|
+
"prompt": {
|
|
139
|
+
"type": "string",
|
|
140
|
+
"description": "A detailed description of the image to generate.",
|
|
141
|
+
},
|
|
142
|
+
"size": {
|
|
143
|
+
"type": "string",
|
|
144
|
+
"description": "Image size as WxH (default: configured size).",
|
|
145
|
+
},
|
|
146
|
+
"model": {
|
|
147
|
+
"type": "string",
|
|
148
|
+
"description": "Image model override (default: configured model).",
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
"required": ["prompt"],
|
|
152
|
+
},
|
|
153
|
+
func=_generate_image,
|
|
154
|
+
runs=True,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _generate_video(args: dict[str, Any], session: Session) -> ToolResult:
|
|
159
|
+
if not config.get_media()["enabled"]:
|
|
160
|
+
return ToolResult.error(
|
|
161
|
+
"Media generation is disabled. Enable it with "
|
|
162
|
+
"`/config media enable on` (or `config media enable on`)."
|
|
163
|
+
)
|
|
164
|
+
resume_id = str(args.get("resume_job_id") or "").strip()
|
|
165
|
+
prompt = str(args.get("prompt") or "").strip()
|
|
166
|
+
if not resume_id and not prompt:
|
|
167
|
+
return ToolResult.error("'prompt' is required (or 'resume_job_id')")
|
|
168
|
+
|
|
169
|
+
first_frame = None
|
|
170
|
+
frame_arg = str(args.get("first_frame") or "").strip()
|
|
171
|
+
if frame_arg and not resume_id:
|
|
172
|
+
try:
|
|
173
|
+
first_frame = resolve_for_read(
|
|
174
|
+
frame_arg,
|
|
175
|
+
session.workspace_root,
|
|
176
|
+
trusted=bool(getattr(session, "trusted_workspace", False)),
|
|
177
|
+
)
|
|
178
|
+
except PathError as exc:
|
|
179
|
+
return ToolResult.error(str(exc))
|
|
180
|
+
if not first_frame.is_file():
|
|
181
|
+
return ToolResult.error(f"File not found: {frame_arg}")
|
|
182
|
+
|
|
183
|
+
duration = args.get("duration")
|
|
184
|
+
size = args.get("size")
|
|
185
|
+
model = args.get("model")
|
|
186
|
+
try:
|
|
187
|
+
if resume_id:
|
|
188
|
+
result = media.resume_video(resume_id, session=session)
|
|
189
|
+
else:
|
|
190
|
+
result = media.generate_video(
|
|
191
|
+
prompt,
|
|
192
|
+
session=session,
|
|
193
|
+
model=str(model).strip() if model else None,
|
|
194
|
+
duration=int(duration) if duration not in (None, "") else None,
|
|
195
|
+
size=str(size).strip() if size else None,
|
|
196
|
+
first_frame=first_frame,
|
|
197
|
+
)
|
|
198
|
+
except (TypeError, ValueError):
|
|
199
|
+
return ToolResult.error("'duration' must be a whole number of seconds")
|
|
200
|
+
except media.MediaError as exc:
|
|
201
|
+
return ToolResult.error(str(exc))
|
|
202
|
+
location = display_path(result.path, session.workspace_root)
|
|
203
|
+
content = (
|
|
204
|
+
f"Generated video saved to {location} ({media.format_bytes(result.bytes)}, "
|
|
205
|
+
f"model {result.model}, job {result.job_id}"
|
|
206
|
+
)
|
|
207
|
+
if result.cost is not None:
|
|
208
|
+
content += f", cost ${result.cost:.2f}"
|
|
209
|
+
return ToolResult(content=content + ")")
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def generate_video_preview(args: dict[str, Any], session: Session) -> str:
|
|
213
|
+
"""Render the approval preview: provider, model, length, size, and prompt."""
|
|
214
|
+
del session
|
|
215
|
+
settings = config.get_media()
|
|
216
|
+
resume_id = str(args.get("resume_job_id") or "").strip()
|
|
217
|
+
if resume_id:
|
|
218
|
+
return (
|
|
219
|
+
f"Provider: {settings['provider']}\n"
|
|
220
|
+
f"Resume video job: {resume_id}\n\n"
|
|
221
|
+
"This fetches a video job that was already started (and billed)."
|
|
222
|
+
)
|
|
223
|
+
prompt = redact(str(args.get("prompt") or "")).strip()
|
|
224
|
+
if len(prompt) > 300:
|
|
225
|
+
prompt = prompt[:297] + "..."
|
|
226
|
+
model = str(args.get("model") or settings["video_model"]).strip()
|
|
227
|
+
duration = args.get("duration") or settings["video_duration"]
|
|
228
|
+
size = str(args.get("size") or settings["video_size"] or "provider default").strip()
|
|
229
|
+
lines = [
|
|
230
|
+
f"Provider: {settings['provider']}",
|
|
231
|
+
f"Model: {model or '(none chosen)'}",
|
|
232
|
+
f"Length: {duration}s",
|
|
233
|
+
f"Size: {size}",
|
|
234
|
+
]
|
|
235
|
+
if args.get("first_frame"):
|
|
236
|
+
lines.append(f"First frame: {args['first_frame']}")
|
|
237
|
+
lines += [
|
|
238
|
+
f"Output: {settings['output_dir']}",
|
|
239
|
+
f"Prompt: {prompt or '(missing)'}",
|
|
240
|
+
"",
|
|
241
|
+
"This calls a paid video API. Video is billed per second of footage "
|
|
242
|
+
"and a clip can cost anywhere from cents to several dollars; it may "
|
|
243
|
+
"take a few minutes to render.",
|
|
244
|
+
]
|
|
245
|
+
return "\n".join(lines)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
generate_video_tool = FunctionTool(
|
|
249
|
+
name="generate_video",
|
|
250
|
+
description=(
|
|
251
|
+
"Generate a short video from a text prompt (optionally animating a "
|
|
252
|
+
"workspace image as the first frame) through the configured media "
|
|
253
|
+
"provider and save it under the media output directory. Slow (minutes) "
|
|
254
|
+
"and billed per second, so use it only when asked for a video. Requires "
|
|
255
|
+
"media generation to be enabled and a video model to be chosen "
|
|
256
|
+
"(`/video model`). If a job times out, call again "
|
|
257
|
+
"with its `resume_job_id` instead of regenerating."
|
|
258
|
+
),
|
|
259
|
+
parameters={
|
|
260
|
+
"type": "object",
|
|
261
|
+
"properties": {
|
|
262
|
+
"prompt": {
|
|
263
|
+
"type": "string",
|
|
264
|
+
"description": "A detailed description of the video to generate.",
|
|
265
|
+
},
|
|
266
|
+
"duration": {
|
|
267
|
+
"type": "integer",
|
|
268
|
+
"description": "Length in seconds (default: configured duration).",
|
|
269
|
+
},
|
|
270
|
+
"size": {
|
|
271
|
+
"type": "string",
|
|
272
|
+
"description": (
|
|
273
|
+
"WxH (e.g. 1280x720) or a tier such as 720p "
|
|
274
|
+
"(default: configured size, else the provider's default)."
|
|
275
|
+
),
|
|
276
|
+
},
|
|
277
|
+
"model": {
|
|
278
|
+
"type": "string",
|
|
279
|
+
"description": "Video model override (default: configured model).",
|
|
280
|
+
},
|
|
281
|
+
"first_frame": {
|
|
282
|
+
"type": "string",
|
|
283
|
+
"description": (
|
|
284
|
+
"Workspace path to an image to animate as the first frame "
|
|
285
|
+
"(image-to-video)."
|
|
286
|
+
),
|
|
287
|
+
},
|
|
288
|
+
"resume_job_id": {
|
|
289
|
+
"type": "string",
|
|
290
|
+
"description": (
|
|
291
|
+
"Job id from an earlier timed-out call; fetches that job "
|
|
292
|
+
"instead of starting (and paying for) a new one."
|
|
293
|
+
),
|
|
294
|
+
},
|
|
295
|
+
},
|
|
296
|
+
"required": [],
|
|
297
|
+
},
|
|
298
|
+
func=_generate_video,
|
|
299
|
+
runs=True,
|
|
300
|
+
)
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
"""Clipboard and HTTP/API testing tools.
|
|
2
|
+
|
|
3
|
+
``read_clipboard`` and ``http_get`` are read-only (safe in plan mode).
|
|
4
|
+
``write_clipboard`` and ``http_request`` execute a clipboard command or can
|
|
5
|
+
mutate remote state, so they set ``runs=True`` and go through the approval
|
|
6
|
+
gate. HTTP responses are rendered with a status line, selected headers, and a
|
|
7
|
+
truncated body; raw ``httpx`` errors are never leaked.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json as json_module
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
from kiwimatecoder import clipboard as clipboard_module
|
|
18
|
+
from kiwimatecoder import config
|
|
19
|
+
from kiwimatecoder import web as web_module
|
|
20
|
+
from kiwimatecoder.redaction import redact
|
|
21
|
+
from kiwimatecoder.session import Session
|
|
22
|
+
from kiwimatecoder.tools.base import FunctionTool, ToolResult
|
|
23
|
+
|
|
24
|
+
HTTP_METHODS = ("GET", "POST", "PUT", "PATCH", "DELETE")
|
|
25
|
+
_PREVIEW_CHARS = 200
|
|
26
|
+
_RESPONSE_HEADER_NAMES = (
|
|
27
|
+
"content-type",
|
|
28
|
+
"content-length",
|
|
29
|
+
"location",
|
|
30
|
+
"server",
|
|
31
|
+
"date",
|
|
32
|
+
"etag",
|
|
33
|
+
"last-modified",
|
|
34
|
+
"cache-control",
|
|
35
|
+
"www-authenticate",
|
|
36
|
+
"x-request-id",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def request_http(
|
|
41
|
+
method: str,
|
|
42
|
+
url: str,
|
|
43
|
+
*,
|
|
44
|
+
headers: dict[str, str] | None = None,
|
|
45
|
+
body: str | None = None,
|
|
46
|
+
json_body: Any = None,
|
|
47
|
+
timeout: float,
|
|
48
|
+
max_chars: int,
|
|
49
|
+
allow_local: bool,
|
|
50
|
+
transport: httpx.BaseTransport | None = None,
|
|
51
|
+
) -> ToolResult:
|
|
52
|
+
"""Perform an HTTP request and render a bounded, model-friendly result.
|
|
53
|
+
|
|
54
|
+
The response status is returned as-is (4xx/5xx are informative for API
|
|
55
|
+
testing), the body is clipped to ``max_chars``, and JSON payloads are
|
|
56
|
+
pretty-printed. ``transport`` exists for tests.
|
|
57
|
+
"""
|
|
58
|
+
method = str(method).strip().upper()
|
|
59
|
+
if method not in HTTP_METHODS:
|
|
60
|
+
return ToolResult.error(
|
|
61
|
+
f"Unknown HTTP method '{method}'. Choose: {', '.join(HTTP_METHODS)}."
|
|
62
|
+
)
|
|
63
|
+
if body is not None and json_body is not None:
|
|
64
|
+
return ToolResult.error("'body' and 'json' are mutually exclusive")
|
|
65
|
+
try:
|
|
66
|
+
cleaned = web_module.clean_url(url)
|
|
67
|
+
except web_module.WebError as exc:
|
|
68
|
+
return ToolResult.error(str(exc))
|
|
69
|
+
if web_module.is_local_address(cleaned) and not allow_local:
|
|
70
|
+
return ToolResult.error(
|
|
71
|
+
"Refusing to call a local or private address. Enable it with "
|
|
72
|
+
"/config web allow-local on (or config web allow-local on)."
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
request_headers = {"User-Agent": web_module.USER_AGENT}
|
|
76
|
+
request_headers.update(headers or {})
|
|
77
|
+
|
|
78
|
+
byte_cap = max(1024, int(max_chars) * web_module.MAX_BYTES_PER_CHAR)
|
|
79
|
+
status_line = "HTTP ?"
|
|
80
|
+
final_url = cleaned
|
|
81
|
+
response_headers: dict[str, str] = {}
|
|
82
|
+
data = bytearray()
|
|
83
|
+
charset = "utf-8"
|
|
84
|
+
try:
|
|
85
|
+
with httpx.Client(
|
|
86
|
+
timeout=timeout, transport=transport, follow_redirects=True
|
|
87
|
+
) as client:
|
|
88
|
+
with client.stream(
|
|
89
|
+
method,
|
|
90
|
+
cleaned,
|
|
91
|
+
headers=request_headers,
|
|
92
|
+
content=body,
|
|
93
|
+
json=json_body,
|
|
94
|
+
) as response:
|
|
95
|
+
status_line = (
|
|
96
|
+
f"HTTP {response.status_code} {response.reason_phrase}"
|
|
97
|
+
).strip()
|
|
98
|
+
final_url = str(response.url)
|
|
99
|
+
response_headers = {
|
|
100
|
+
key.lower(): value for key, value in response.headers.items()
|
|
101
|
+
}
|
|
102
|
+
charset = response.charset_encoding or "utf-8"
|
|
103
|
+
for chunk in response.iter_bytes():
|
|
104
|
+
data.extend(chunk)
|
|
105
|
+
if len(data) >= byte_cap:
|
|
106
|
+
del data[byte_cap:]
|
|
107
|
+
break
|
|
108
|
+
except httpx.HTTPError as exc:
|
|
109
|
+
return ToolResult.error(f"Request failed: {exc.__class__.__name__}.")
|
|
110
|
+
|
|
111
|
+
text = bytes(data).decode(charset, errors="replace")
|
|
112
|
+
mime = (response_headers.get("content-type") or "").split(";", 1)[0].strip().lower()
|
|
113
|
+
if mime == "application/json" or mime.endswith("+json"):
|
|
114
|
+
try:
|
|
115
|
+
text = json_module.dumps(
|
|
116
|
+
json_module.loads(text), indent=2, ensure_ascii=False
|
|
117
|
+
)
|
|
118
|
+
except (json_module.JSONDecodeError, ValueError):
|
|
119
|
+
pass
|
|
120
|
+
if len(text) > max_chars:
|
|
121
|
+
text = text[:max_chars] + f"\n\n[truncated at {max_chars} characters]"
|
|
122
|
+
|
|
123
|
+
lines = [status_line]
|
|
124
|
+
if final_url != cleaned:
|
|
125
|
+
lines.append(f"URL: {final_url}")
|
|
126
|
+
lines.extend(
|
|
127
|
+
f"{name}: {response_headers[name]}"
|
|
128
|
+
for name in _RESPONSE_HEADER_NAMES
|
|
129
|
+
if name in response_headers
|
|
130
|
+
)
|
|
131
|
+
rendered = "\n".join(lines)
|
|
132
|
+
if text:
|
|
133
|
+
rendered += "\n\n" + text
|
|
134
|
+
return ToolResult(content=rendered)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _string_headers(raw: Any) -> tuple[dict[str, str] | None, str | None]:
|
|
138
|
+
if raw is None:
|
|
139
|
+
return {}, None
|
|
140
|
+
if not isinstance(raw, dict):
|
|
141
|
+
return None, "'headers' must be an object of string values"
|
|
142
|
+
return {str(key): str(value) for key, value in raw.items()}, None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _http_call(
|
|
146
|
+
args: dict[str, Any], session: Session, *, method: str | None = None
|
|
147
|
+
) -> ToolResult:
|
|
148
|
+
del session
|
|
149
|
+
url = str(args.get("url") or "").strip()
|
|
150
|
+
if not url:
|
|
151
|
+
return ToolResult.error("'url' is required")
|
|
152
|
+
headers, error = _string_headers(args.get("headers"))
|
|
153
|
+
if error:
|
|
154
|
+
return ToolResult.error(error)
|
|
155
|
+
|
|
156
|
+
settings = config.get_web()
|
|
157
|
+
timeout = float(settings["timeout"])
|
|
158
|
+
raw_timeout = args.get("timeout")
|
|
159
|
+
if raw_timeout is not None:
|
|
160
|
+
try:
|
|
161
|
+
timeout = float(raw_timeout)
|
|
162
|
+
except (TypeError, ValueError):
|
|
163
|
+
return ToolResult.error("'timeout' must be a number of seconds")
|
|
164
|
+
if not config.WEB_TIMEOUT_MIN <= timeout <= config.WEB_TIMEOUT_MAX:
|
|
165
|
+
return ToolResult.error(
|
|
166
|
+
f"'timeout' must be between {config.WEB_TIMEOUT_MIN:g} and "
|
|
167
|
+
f"{config.WEB_TIMEOUT_MAX:g} seconds"
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
body = args.get("body")
|
|
171
|
+
if body is not None and not isinstance(body, str):
|
|
172
|
+
return ToolResult.error("'body' must be a string")
|
|
173
|
+
json_body = args.get("json")
|
|
174
|
+
if body is not None and json_body is not None:
|
|
175
|
+
return ToolResult.error("'body' and 'json' are mutually exclusive")
|
|
176
|
+
|
|
177
|
+
chosen = method or str(args.get("method") or "").strip()
|
|
178
|
+
return request_http(
|
|
179
|
+
chosen,
|
|
180
|
+
url,
|
|
181
|
+
headers=headers,
|
|
182
|
+
body=body,
|
|
183
|
+
json_body=json_body,
|
|
184
|
+
timeout=timeout,
|
|
185
|
+
max_chars=int(settings["max_chars"]),
|
|
186
|
+
allow_local=bool(settings["allow_local"]),
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _read_clipboard(args: dict[str, Any], session: Session) -> ToolResult:
|
|
191
|
+
del args, session
|
|
192
|
+
ok, payload = clipboard_module.read_clipboard()
|
|
193
|
+
if not ok:
|
|
194
|
+
return ToolResult.error(payload)
|
|
195
|
+
return ToolResult(content=payload if payload else "(clipboard is empty)")
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _write_clipboard(args: dict[str, Any], session: Session) -> ToolResult:
|
|
199
|
+
del session
|
|
200
|
+
text = args.get("text")
|
|
201
|
+
if text is None or str(text) == "":
|
|
202
|
+
return ToolResult.error("'text' is required")
|
|
203
|
+
ok, message = clipboard_module.write_clipboard(str(text))
|
|
204
|
+
if not ok:
|
|
205
|
+
return ToolResult.error(message)
|
|
206
|
+
return ToolResult(content=message)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _clip(text: str) -> str:
|
|
210
|
+
return text if len(text) <= _PREVIEW_CHARS else text[:_PREVIEW_CHARS] + "…"
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def write_clipboard_preview(args: dict[str, Any], session: Session) -> str:
|
|
214
|
+
"""Show (redacted, clipped) text for the write_clipboard approval prompt."""
|
|
215
|
+
del session
|
|
216
|
+
return f"Copy to clipboard:\n{_clip(redact(str(args.get('text') or ''))) or '(empty)'}"
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def http_request_preview(args: dict[str, Any], session: Session) -> str:
|
|
220
|
+
"""Show the redacted request for the http_request approval prompt."""
|
|
221
|
+
del session
|
|
222
|
+
method = str(args.get("method") or "GET").strip().upper()
|
|
223
|
+
lines = [f"{method} {str(args.get('url') or '')}"]
|
|
224
|
+
headers = args.get("headers")
|
|
225
|
+
if isinstance(headers, dict):
|
|
226
|
+
lines.extend(f"{key}: {redact(str(value))}" for key, value in headers.items())
|
|
227
|
+
body = args.get("body")
|
|
228
|
+
json_body = args.get("json")
|
|
229
|
+
if body is not None:
|
|
230
|
+
lines.append(_clip(redact(str(body))))
|
|
231
|
+
elif json_body is not None:
|
|
232
|
+
try:
|
|
233
|
+
payload = json_module.dumps(json_body, ensure_ascii=False)
|
|
234
|
+
except (TypeError, ValueError):
|
|
235
|
+
payload = str(json_body)
|
|
236
|
+
lines.append(_clip(redact(payload)))
|
|
237
|
+
return "\n".join(lines)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _http_get(args: dict[str, Any], session: Session) -> ToolResult:
|
|
241
|
+
return _http_call(args, session, method="GET")
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _http_request(args: dict[str, Any], session: Session) -> ToolResult:
|
|
245
|
+
return _http_call(args, session)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
read_clipboard_tool = FunctionTool(
|
|
249
|
+
name="read_clipboard",
|
|
250
|
+
description=(
|
|
251
|
+
"Read the system clipboard as text. Read-only, so it runs without "
|
|
252
|
+
"approval and stays available in plan mode."
|
|
253
|
+
),
|
|
254
|
+
parameters={"type": "object", "properties": {}},
|
|
255
|
+
func=_read_clipboard,
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
write_clipboard_tool = FunctionTool(
|
|
259
|
+
name="write_clipboard",
|
|
260
|
+
description=(
|
|
261
|
+
"Copy text to the system clipboard. Requires approval; the preview "
|
|
262
|
+
"shows the exact text that will be copied."
|
|
263
|
+
),
|
|
264
|
+
parameters={
|
|
265
|
+
"type": "object",
|
|
266
|
+
"properties": {
|
|
267
|
+
"text": {
|
|
268
|
+
"type": "string",
|
|
269
|
+
"description": "Text to place on the clipboard.",
|
|
270
|
+
},
|
|
271
|
+
},
|
|
272
|
+
"required": ["text"],
|
|
273
|
+
},
|
|
274
|
+
func=_write_clipboard,
|
|
275
|
+
runs=True,
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
http_get_tool = FunctionTool(
|
|
279
|
+
name="http_get",
|
|
280
|
+
description=(
|
|
281
|
+
"Fetch an http(s) URL and return its status line, selected response "
|
|
282
|
+
"headers, and body (JSON is pretty-printed). Read-only. Local and "
|
|
283
|
+
"private addresses are blocked unless enabled in /config web; for "
|
|
284
|
+
"other methods use http_request."
|
|
285
|
+
),
|
|
286
|
+
parameters={
|
|
287
|
+
"type": "object",
|
|
288
|
+
"properties": {
|
|
289
|
+
"url": {
|
|
290
|
+
"type": "string",
|
|
291
|
+
"description": "Absolute http(s) URL to request.",
|
|
292
|
+
},
|
|
293
|
+
"headers": {
|
|
294
|
+
"type": "object",
|
|
295
|
+
"description": "Optional request headers (name -> value).",
|
|
296
|
+
"additionalProperties": {"type": "string"},
|
|
297
|
+
},
|
|
298
|
+
},
|
|
299
|
+
"required": ["url"],
|
|
300
|
+
},
|
|
301
|
+
func=_http_get,
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
http_request_tool = FunctionTool(
|
|
305
|
+
name="http_request",
|
|
306
|
+
description=(
|
|
307
|
+
"Send an HTTP request with GET, POST, PUT, PATCH, or DELETE and return "
|
|
308
|
+
"the status line, selected response headers, and body (JSON is "
|
|
309
|
+
"pretty-printed). Requires approval because it can change remote state. "
|
|
310
|
+
"Use 'body' for raw text or 'json' for a JSON payload, not both. Local "
|
|
311
|
+
"and private addresses are blocked unless enabled in /config web."
|
|
312
|
+
),
|
|
313
|
+
parameters={
|
|
314
|
+
"type": "object",
|
|
315
|
+
"properties": {
|
|
316
|
+
"method": {
|
|
317
|
+
"type": "string",
|
|
318
|
+
"enum": list(HTTP_METHODS),
|
|
319
|
+
"description": "HTTP method.",
|
|
320
|
+
},
|
|
321
|
+
"url": {
|
|
322
|
+
"type": "string",
|
|
323
|
+
"description": "Absolute http(s) URL to request.",
|
|
324
|
+
},
|
|
325
|
+
"headers": {
|
|
326
|
+
"type": "object",
|
|
327
|
+
"description": "Optional request headers (name -> value).",
|
|
328
|
+
"additionalProperties": {"type": "string"},
|
|
329
|
+
},
|
|
330
|
+
"body": {
|
|
331
|
+
"type": "string",
|
|
332
|
+
"description": "Raw request body (mutually exclusive with json).",
|
|
333
|
+
},
|
|
334
|
+
"json": {
|
|
335
|
+
"description": "JSON request body (mutually exclusive with body).",
|
|
336
|
+
},
|
|
337
|
+
"timeout": {
|
|
338
|
+
"type": "number",
|
|
339
|
+
"description": "Seconds before the request times out (default: configured).",
|
|
340
|
+
},
|
|
341
|
+
},
|
|
342
|
+
"required": ["method", "url"],
|
|
343
|
+
},
|
|
344
|
+
func=_http_request,
|
|
345
|
+
runs=True,
|
|
346
|
+
)
|