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
|
+
"""Apply a hunk-level approval selection to file-changing tool arguments."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import difflib
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from kiwimatecoder import hunks
|
|
10
|
+
from kiwimatecoder.session import Session
|
|
11
|
+
from kiwimatecoder.tools.edit_file import compute_new_content
|
|
12
|
+
from kiwimatecoder.tools.paths import PathError, display_path, resolve_in_workspace
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _unified_diff(old_text: str, new_text: str, rel: str) -> str:
|
|
16
|
+
return "".join(
|
|
17
|
+
difflib.unified_diff(
|
|
18
|
+
old_text.splitlines(keepends=True),
|
|
19
|
+
new_text.splitlines(keepends=True),
|
|
20
|
+
fromfile=rel,
|
|
21
|
+
tofile=rel,
|
|
22
|
+
n=3,
|
|
23
|
+
)
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _selection_in_range(selected: Sequence[int], count: int) -> bool:
|
|
28
|
+
return all(1 <= index <= count for index in selected)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _select_write(
|
|
32
|
+
args: dict[str, Any], session: Session, selected: Sequence[int]
|
|
33
|
+
) -> dict[str, Any] | None:
|
|
34
|
+
path = str(args.get("path") or "")
|
|
35
|
+
if not path:
|
|
36
|
+
return None
|
|
37
|
+
content = str(args.get("content", "") or "")
|
|
38
|
+
try:
|
|
39
|
+
resolved = resolve_in_workspace(path, session.workspace_root)
|
|
40
|
+
except PathError:
|
|
41
|
+
return None
|
|
42
|
+
if resolved.exists():
|
|
43
|
+
old_text = resolved.read_text(encoding="utf-8", errors="replace")
|
|
44
|
+
else:
|
|
45
|
+
old_text = ""
|
|
46
|
+
rel = display_path(resolved, session.workspace_root)
|
|
47
|
+
diff = _unified_diff(old_text, content, rel)
|
|
48
|
+
if not diff:
|
|
49
|
+
return {**args}
|
|
50
|
+
parsed = hunks.split_hunks(diff)
|
|
51
|
+
if not parsed or not _selection_in_range(selected, len(parsed)):
|
|
52
|
+
return None
|
|
53
|
+
new_content = hunks.apply_selected_hunks(old_text, diff, selected)
|
|
54
|
+
if new_content == old_text:
|
|
55
|
+
return None
|
|
56
|
+
return {**args, "content": new_content}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _select_edit(
|
|
60
|
+
args: dict[str, Any], session: Session, selected: Sequence[int]
|
|
61
|
+
) -> dict[str, Any] | None:
|
|
62
|
+
path = str(args.get("path") or "")
|
|
63
|
+
if not path:
|
|
64
|
+
return None
|
|
65
|
+
try:
|
|
66
|
+
resolved = resolve_in_workspace(path, session.workspace_root)
|
|
67
|
+
except PathError:
|
|
68
|
+
return None
|
|
69
|
+
if not resolved.exists():
|
|
70
|
+
return None
|
|
71
|
+
old_text = resolved.read_text(encoding="utf-8", errors="replace")
|
|
72
|
+
new_text = compute_new_content(old_text, args)
|
|
73
|
+
if new_text is None:
|
|
74
|
+
return None
|
|
75
|
+
rel = display_path(resolved, session.workspace_root)
|
|
76
|
+
diff = _unified_diff(old_text, new_text, rel)
|
|
77
|
+
if not diff:
|
|
78
|
+
return {**args}
|
|
79
|
+
parsed = hunks.split_hunks(diff)
|
|
80
|
+
if not parsed or not _selection_in_range(selected, len(parsed)):
|
|
81
|
+
return None
|
|
82
|
+
new_content = hunks.apply_selected_hunks(old_text, diff, selected)
|
|
83
|
+
if new_content == old_text:
|
|
84
|
+
return None
|
|
85
|
+
return {
|
|
86
|
+
**args,
|
|
87
|
+
"old_string": old_text,
|
|
88
|
+
"new_string": new_content,
|
|
89
|
+
"replace_all": False,
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def select_hunks(
|
|
94
|
+
name: str,
|
|
95
|
+
args: dict[str, Any],
|
|
96
|
+
session: Session,
|
|
97
|
+
selected_hunks: Sequence[int],
|
|
98
|
+
) -> dict[str, Any] | None:
|
|
99
|
+
"""Return ``args`` rewritten to apply only ``selected_hunks``.
|
|
100
|
+
|
|
101
|
+
Returns ``None`` when the selection cannot be applied; callers should treat
|
|
102
|
+
that as a denial. Tools without hunk support also return ``None``.
|
|
103
|
+
"""
|
|
104
|
+
if name == "write_file":
|
|
105
|
+
return _select_write(args, session, selected_hunks)
|
|
106
|
+
if name == "edit_file":
|
|
107
|
+
return _select_edit(args, session, selected_hunks)
|
|
108
|
+
return None
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Persistent-shell tools: stateful commands and background process control.
|
|
2
|
+
|
|
3
|
+
``shell`` runs a command in the session's long-lived shell, so ``cd`` and
|
|
4
|
+
exported environment variables persist between calls. ``shell_jobs`` starts,
|
|
5
|
+
lists, reads, and kills detached background commands. Both are approval-gated.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from kiwimatecoder.remote import remote_preview
|
|
13
|
+
from kiwimatecoder.shell import (
|
|
14
|
+
ShellDisabledError,
|
|
15
|
+
ShellError,
|
|
16
|
+
ShellJobLimitError,
|
|
17
|
+
get_shell,
|
|
18
|
+
)
|
|
19
|
+
from kiwimatecoder.tools.base import FunctionTool, ToolResult
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def preview(args: dict[str, Any], session: Any) -> str:
|
|
23
|
+
"""Return the command text (and remote wrapper) for the approval prompt."""
|
|
24
|
+
command = str(args.get("command", ""))
|
|
25
|
+
return remote_preview(command, workspace=session.workspace_root)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def jobs_preview(args: dict[str, Any], session: Any) -> str:
|
|
29
|
+
"""Return a short description of a background-job action for approval."""
|
|
30
|
+
action = str(args.get("action") or "").strip().lower()
|
|
31
|
+
if action == "start":
|
|
32
|
+
command = str(args.get("command", ""))
|
|
33
|
+
return f"start: {remote_preview(command, workspace=session.workspace_root)}"
|
|
34
|
+
if action in ("output", "kill"):
|
|
35
|
+
return f"{action}: {args.get('id', '')}"
|
|
36
|
+
return action or "list"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _run_shell(args: dict[str, Any], session: Any) -> ToolResult:
|
|
40
|
+
command = str(args.get("command") or "")
|
|
41
|
+
if not command.strip():
|
|
42
|
+
return ToolResult.error("'command' is required")
|
|
43
|
+
try:
|
|
44
|
+
exit_code, output = get_shell(session).run(command)
|
|
45
|
+
except ShellDisabledError as exc:
|
|
46
|
+
return ToolResult.error(str(exc))
|
|
47
|
+
except ShellError as exc:
|
|
48
|
+
return ToolResult.error(str(exc))
|
|
49
|
+
|
|
50
|
+
parts = [output] if output else []
|
|
51
|
+
parts.append(f"[exit code: {exit_code}]")
|
|
52
|
+
return ToolResult(content="\n".join(parts), ok=exit_code == 0)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _shell_jobs(args: dict[str, Any], session: Any) -> ToolResult:
|
|
56
|
+
action = str(args.get("action") or "").strip().lower()
|
|
57
|
+
if not action:
|
|
58
|
+
return ToolResult.error("'action' is required (start, list, output, kill)")
|
|
59
|
+
manager = get_shell(session)
|
|
60
|
+
|
|
61
|
+
if action == "start":
|
|
62
|
+
command = str(args.get("command") or "")
|
|
63
|
+
if not command.strip():
|
|
64
|
+
return ToolResult.error("'command' is required for action 'start'")
|
|
65
|
+
try:
|
|
66
|
+
job_id = manager.start_background(command)
|
|
67
|
+
except ShellJobLimitError as exc:
|
|
68
|
+
return ToolResult.error(str(exc))
|
|
69
|
+
except ShellError as exc:
|
|
70
|
+
return ToolResult.error(str(exc))
|
|
71
|
+
job = manager.get_background(job_id)
|
|
72
|
+
output_path = str(job.output_path) if job is not None else ""
|
|
73
|
+
return ToolResult(
|
|
74
|
+
content=(
|
|
75
|
+
f"Started background job {job_id} (output at {output_path}). "
|
|
76
|
+
"Use shell_jobs with action 'output' to read it."
|
|
77
|
+
)
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
if action == "list":
|
|
81
|
+
jobs = manager.list_background()
|
|
82
|
+
if not jobs:
|
|
83
|
+
return ToolResult(content="No background jobs.")
|
|
84
|
+
lines = []
|
|
85
|
+
for job in jobs:
|
|
86
|
+
state = "running" if job.alive else f"exited ({job.exit_code})"
|
|
87
|
+
lines.append(f"{job.id} [{state}] {job.command}")
|
|
88
|
+
return ToolResult(content="\n".join(lines))
|
|
89
|
+
|
|
90
|
+
job_id = str(args.get("id") or "").strip()
|
|
91
|
+
if not job_id:
|
|
92
|
+
return ToolResult.error(f"'id' is required for action '{action}'")
|
|
93
|
+
|
|
94
|
+
if action == "output":
|
|
95
|
+
text = manager.output_background(job_id)
|
|
96
|
+
if text is None:
|
|
97
|
+
return ToolResult.error(f"Unknown background job '{job_id}'")
|
|
98
|
+
return ToolResult(content=text or "(no output yet)")
|
|
99
|
+
|
|
100
|
+
if action == "kill":
|
|
101
|
+
if not manager.kill_background(job_id):
|
|
102
|
+
return ToolResult.error(f"Unknown background job '{job_id}'")
|
|
103
|
+
return ToolResult(content=f"Killed background job {job_id}.")
|
|
104
|
+
|
|
105
|
+
return ToolResult.error("Unknown action; choose start, list, output, or kill")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
shell_tool = FunctionTool(
|
|
109
|
+
name="shell",
|
|
110
|
+
description=(
|
|
111
|
+
"Run a command in a persistent shell that keeps its working directory "
|
|
112
|
+
"and exported environment between calls. Prefer this over run_bash when "
|
|
113
|
+
"state must persist across commands (e.g. `cd`, `export`, virtualenv "
|
|
114
|
+
"activation). Returns the exit code and combined output."
|
|
115
|
+
),
|
|
116
|
+
parameters={
|
|
117
|
+
"type": "object",
|
|
118
|
+
"properties": {
|
|
119
|
+
"command": {
|
|
120
|
+
"type": "string",
|
|
121
|
+
"description": "The shell command to execute.",
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
"required": ["command"],
|
|
125
|
+
},
|
|
126
|
+
func=_run_shell,
|
|
127
|
+
runs=True,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
shell_jobs_tool = FunctionTool(
|
|
131
|
+
name="shell_jobs",
|
|
132
|
+
description=(
|
|
133
|
+
"Manage background shell processes. Actions: start (run a command "
|
|
134
|
+
"detached; 'command' required), list, output ('id' required), kill "
|
|
135
|
+
"('id' required). Background output is captured to a file."
|
|
136
|
+
),
|
|
137
|
+
parameters={
|
|
138
|
+
"type": "object",
|
|
139
|
+
"properties": {
|
|
140
|
+
"action": {
|
|
141
|
+
"type": "string",
|
|
142
|
+
"enum": ["start", "list", "output", "kill"],
|
|
143
|
+
"description": "The background-job action to perform.",
|
|
144
|
+
},
|
|
145
|
+
"command": {
|
|
146
|
+
"type": "string",
|
|
147
|
+
"description": "Command to start in the background (action 'start').",
|
|
148
|
+
},
|
|
149
|
+
"id": {
|
|
150
|
+
"type": "string",
|
|
151
|
+
"description": "Background job id (actions 'output' and 'kill').",
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
"required": ["action"],
|
|
155
|
+
},
|
|
156
|
+
func=_shell_jobs,
|
|
157
|
+
runs=True,
|
|
158
|
+
)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""load_skill tool: fetch an Agent Skill's full instructions on demand."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from kiwimatecoder.session import Session
|
|
8
|
+
from kiwimatecoder.skills import discover_skills, load_skill as _load_skill
|
|
9
|
+
from kiwimatecoder.tools.base import FunctionTool, ToolResult
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _load_skill_tool(args: dict[str, Any], session: Session) -> ToolResult:
|
|
13
|
+
name = str(args.get("name") or "").strip()
|
|
14
|
+
if not name:
|
|
15
|
+
return ToolResult.error("'name' is required")
|
|
16
|
+
content = _load_skill(name, session.workspace_root)
|
|
17
|
+
if content is None:
|
|
18
|
+
available = [skill.name for skill in discover_skills(session.workspace_root)]
|
|
19
|
+
hint = ", ".join(available) if available else "none"
|
|
20
|
+
return ToolResult.error(
|
|
21
|
+
f"Unknown skill '{name}'. Available skills: {hint}."
|
|
22
|
+
)
|
|
23
|
+
return ToolResult(content=content)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
load_skill_tool = FunctionTool(
|
|
27
|
+
name="load_skill",
|
|
28
|
+
description=(
|
|
29
|
+
"Load the full instructions for an available Agent Skill by name. "
|
|
30
|
+
"Use this when the current task matches a skill listed in the system "
|
|
31
|
+
"prompt; the return value is the skill's complete markdown guide."
|
|
32
|
+
),
|
|
33
|
+
parameters={
|
|
34
|
+
"type": "object",
|
|
35
|
+
"properties": {
|
|
36
|
+
"name": {
|
|
37
|
+
"type": "string",
|
|
38
|
+
"description": "Name of the skill to load, exactly as listed.",
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
"required": ["name"],
|
|
42
|
+
},
|
|
43
|
+
func=_load_skill_tool,
|
|
44
|
+
writes=False,
|
|
45
|
+
runs=False,
|
|
46
|
+
)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""task tool: delegate a focused investigation to a subagent.
|
|
2
|
+
|
|
3
|
+
The tool is declared with ``runs=True`` so spawning a subagent is
|
|
4
|
+
approval-gated in ASK mode: the subagent can write files and run commands, and
|
|
5
|
+
its own actions still go through the normal permission gate.
|
|
6
|
+
|
|
7
|
+
Execution itself is special-cased in :class:`kiwimatecoder.agent.Agent` (the
|
|
8
|
+
subagent runs an async loop), so the sync callable below only exists for
|
|
9
|
+
dispatch paths without an agent driving them.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from kiwimatecoder.session import Session
|
|
17
|
+
from kiwimatecoder.tools.base import FunctionTool, ToolResult
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _run_task(args: dict[str, Any], session: Session) -> ToolResult:
|
|
21
|
+
return ToolResult.error(
|
|
22
|
+
"The task tool must run inside the agent loop; use it from a normal "
|
|
23
|
+
"conversation turn."
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
task_tool = FunctionTool(
|
|
28
|
+
name="task",
|
|
29
|
+
description=(
|
|
30
|
+
"Delegate a focused, self-contained investigation to a subagent that "
|
|
31
|
+
"runs in its own context with the same workspace and tools. It cannot "
|
|
32
|
+
"ask the user questions and it cannot delegate further, so put every "
|
|
33
|
+
"needed decision in the prompt. It returns a final report and its "
|
|
34
|
+
"intermediate tool output stays out of this conversation. Its file "
|
|
35
|
+
"writes and commands still require the same approvals as yours."
|
|
36
|
+
),
|
|
37
|
+
parameters={
|
|
38
|
+
"type": "object",
|
|
39
|
+
"properties": {
|
|
40
|
+
"description": {
|
|
41
|
+
"type": "string",
|
|
42
|
+
"description": "Short (3-5 word) description of the task.",
|
|
43
|
+
},
|
|
44
|
+
"prompt": {
|
|
45
|
+
"type": "string",
|
|
46
|
+
"description": (
|
|
47
|
+
"Complete, self-contained instructions for the subagent, "
|
|
48
|
+
"including what to report back."
|
|
49
|
+
),
|
|
50
|
+
},
|
|
51
|
+
"model": {
|
|
52
|
+
"type": "string",
|
|
53
|
+
"description": "Optional model override for this subagent.",
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
"required": ["description", "prompt"],
|
|
57
|
+
},
|
|
58
|
+
func=_run_task,
|
|
59
|
+
writes=False,
|
|
60
|
+
runs=True,
|
|
61
|
+
)
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""update_todos tool: keep an explicit task list for multi-step work."""
|
|
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
|
+
VALID_STATUSES = ("pending", "in_progress", "completed")
|
|
11
|
+
_MARKERS = {"pending": "[ ]", "in_progress": "[~]", "completed": "[x]"}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _update_todos(args: dict[str, Any], session: Session) -> ToolResult:
|
|
15
|
+
raw = args.get("todos")
|
|
16
|
+
if not isinstance(raw, list):
|
|
17
|
+
return ToolResult.error("'todos' must be a list of {content, status} objects")
|
|
18
|
+
|
|
19
|
+
cleaned: list[dict[str, str]] = []
|
|
20
|
+
for item in raw:
|
|
21
|
+
if not isinstance(item, dict):
|
|
22
|
+
return ToolResult.error(
|
|
23
|
+
"each todo must be an object with 'content' and 'status'"
|
|
24
|
+
)
|
|
25
|
+
content = str(item.get("content") or "").strip()
|
|
26
|
+
status = str(item.get("status") or "pending").strip().lower()
|
|
27
|
+
if not content:
|
|
28
|
+
return ToolResult.error("todo 'content' is required")
|
|
29
|
+
if status not in VALID_STATUSES:
|
|
30
|
+
return ToolResult.error(
|
|
31
|
+
f"Unknown todo status '{status}'. "
|
|
32
|
+
f"Choose: {', '.join(VALID_STATUSES)}."
|
|
33
|
+
)
|
|
34
|
+
cleaned.append({"content": content, "status": status})
|
|
35
|
+
|
|
36
|
+
session.todos = cleaned
|
|
37
|
+
if not cleaned:
|
|
38
|
+
return ToolResult(content="Task list cleared.")
|
|
39
|
+
|
|
40
|
+
lines = [f"{_MARKERS[t['status']]} {t['content']}" for t in cleaned]
|
|
41
|
+
done = sum(1 for todo in cleaned if todo["status"] == "completed")
|
|
42
|
+
return ToolResult(content="\n".join(lines) + f"\n\n{done}/{len(cleaned)} complete.")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
update_todos_tool = FunctionTool(
|
|
46
|
+
name="update_todos",
|
|
47
|
+
description=(
|
|
48
|
+
"Replace the task list with the given items. Use it for multi-step work: "
|
|
49
|
+
"keep exactly one task 'in_progress' at a time and mark tasks "
|
|
50
|
+
"'completed' as soon as they are done. Passing an empty list clears it."
|
|
51
|
+
),
|
|
52
|
+
parameters={
|
|
53
|
+
"type": "object",
|
|
54
|
+
"properties": {
|
|
55
|
+
"todos": {
|
|
56
|
+
"type": "array",
|
|
57
|
+
"description": "The full task list, in order.",
|
|
58
|
+
"items": {
|
|
59
|
+
"type": "object",
|
|
60
|
+
"properties": {
|
|
61
|
+
"content": {
|
|
62
|
+
"type": "string",
|
|
63
|
+
"description": "Short imperative description of the task.",
|
|
64
|
+
},
|
|
65
|
+
"status": {
|
|
66
|
+
"type": "string",
|
|
67
|
+
"enum": list(VALID_STATUSES),
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
"required": ["content", "status"],
|
|
71
|
+
},
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
"required": ["todos"],
|
|
75
|
+
},
|
|
76
|
+
func=_update_todos,
|
|
77
|
+
)
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""web_fetch and web_search tools: read the web with citation-friendly output."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from kiwimatecoder import config
|
|
8
|
+
from kiwimatecoder import web as web_module
|
|
9
|
+
from kiwimatecoder.session import Session
|
|
10
|
+
from kiwimatecoder.tools.base import FunctionTool, ToolResult
|
|
11
|
+
|
|
12
|
+
DEFAULT_FETCH_MAX_CHARS = 50_000
|
|
13
|
+
DEFAULT_SEARCH_COUNT = 5
|
|
14
|
+
MAX_SEARCH_COUNT = 10
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _fetch_max_chars(args: dict[str, Any], default: int) -> tuple[int, str | None]:
|
|
18
|
+
raw = args.get("max_chars")
|
|
19
|
+
if raw is None:
|
|
20
|
+
return default, None
|
|
21
|
+
try:
|
|
22
|
+
value = int(raw)
|
|
23
|
+
except (TypeError, ValueError):
|
|
24
|
+
return default, "'max_chars' must be an integer"
|
|
25
|
+
return max(config.WEB_MAX_CHARS_MIN, min(value, config.WEB_MAX_CHARS_MAX)), None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _web_fetch(args: dict[str, Any], session: Session) -> ToolResult:
|
|
29
|
+
del session # fetching does not depend on the workspace
|
|
30
|
+
url = str(args.get("url") or "").strip()
|
|
31
|
+
if not url:
|
|
32
|
+
return ToolResult.error("'url' is required")
|
|
33
|
+
settings = config.get_web()
|
|
34
|
+
max_chars, error = _fetch_max_chars(args, settings["max_chars"])
|
|
35
|
+
if error:
|
|
36
|
+
return ToolResult.error(error)
|
|
37
|
+
try:
|
|
38
|
+
final_url, text = web_module.fetch_url(
|
|
39
|
+
url,
|
|
40
|
+
timeout=settings["timeout"],
|
|
41
|
+
max_chars=max_chars,
|
|
42
|
+
allow_local=settings["allow_local"],
|
|
43
|
+
)
|
|
44
|
+
except web_module.WebError as exc:
|
|
45
|
+
return ToolResult.error(str(exc))
|
|
46
|
+
|
|
47
|
+
if not text.strip():
|
|
48
|
+
return ToolResult(
|
|
49
|
+
content=f"Fetched {final_url} but found no readable text content."
|
|
50
|
+
)
|
|
51
|
+
truncated = len(text) > max_chars
|
|
52
|
+
if truncated:
|
|
53
|
+
text = text[:max_chars] + f"\n\n[truncated at {max_chars} characters]"
|
|
54
|
+
header = f"URL: {final_url}\n\n" if final_url != url else ""
|
|
55
|
+
return ToolResult(content=header + text)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _web_search(args: dict[str, Any], session: Session) -> ToolResult:
|
|
59
|
+
del session
|
|
60
|
+
query = str(args.get("query") or "").strip()
|
|
61
|
+
if not query:
|
|
62
|
+
return ToolResult.error("'query' is required")
|
|
63
|
+
raw_count = args.get("count")
|
|
64
|
+
try:
|
|
65
|
+
count = int(raw_count) if raw_count is not None else DEFAULT_SEARCH_COUNT
|
|
66
|
+
except (TypeError, ValueError):
|
|
67
|
+
return ToolResult.error("'count' must be an integer")
|
|
68
|
+
count = max(1, min(count, MAX_SEARCH_COUNT))
|
|
69
|
+
|
|
70
|
+
settings = config.get_web()
|
|
71
|
+
try:
|
|
72
|
+
results = web_module.search_web(
|
|
73
|
+
query,
|
|
74
|
+
count=count,
|
|
75
|
+
timeout=settings["timeout"],
|
|
76
|
+
provider=settings["search_provider"],
|
|
77
|
+
api_key=settings["search_api_key"] or None,
|
|
78
|
+
)
|
|
79
|
+
except web_module.WebError as exc:
|
|
80
|
+
return ToolResult.error(str(exc))
|
|
81
|
+
|
|
82
|
+
if not results:
|
|
83
|
+
return ToolResult(content=f"No results found for {query!r}.")
|
|
84
|
+
lines = [f"Results for {query!r} ({len(results)}):", ""]
|
|
85
|
+
for index, result in enumerate(results, 1):
|
|
86
|
+
lines.append(f"{index}. {result.title}")
|
|
87
|
+
lines.append(f" {result.url}")
|
|
88
|
+
if result.snippet:
|
|
89
|
+
lines.append(f" {result.snippet}")
|
|
90
|
+
lines.append("")
|
|
91
|
+
lines.append("Cite the URLs you use; prefer official documentation over mirrors.")
|
|
92
|
+
return ToolResult(content="\n".join(lines).rstrip())
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
web_fetch_tool = FunctionTool(
|
|
96
|
+
name="web_fetch",
|
|
97
|
+
description=(
|
|
98
|
+
"Fetch a web page and return its readable text. Use it to consult "
|
|
99
|
+
"official documentation and cite the URL in your answer. Prefer "
|
|
100
|
+
"official docs and primary sources over aggregator pages. HTML is "
|
|
101
|
+
"converted to text; binary files are summarized. Local and private "
|
|
102
|
+
"addresses are blocked unless enabled in /config web."
|
|
103
|
+
),
|
|
104
|
+
parameters={
|
|
105
|
+
"type": "object",
|
|
106
|
+
"properties": {
|
|
107
|
+
"url": {
|
|
108
|
+
"type": "string",
|
|
109
|
+
"description": "Absolute http(s) URL to fetch.",
|
|
110
|
+
},
|
|
111
|
+
"max_chars": {
|
|
112
|
+
"type": "integer",
|
|
113
|
+
"description": (
|
|
114
|
+
"Maximum characters to return (defaults to the configured "
|
|
115
|
+
"limit)."
|
|
116
|
+
),
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
"required": ["url"],
|
|
120
|
+
},
|
|
121
|
+
func=_web_fetch,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
web_search_tool = FunctionTool(
|
|
125
|
+
name="web_search",
|
|
126
|
+
description=(
|
|
127
|
+
"Search the web and return result titles, URLs, and snippets. Use it "
|
|
128
|
+
"to find current information, then fetch the most promising URLs. "
|
|
129
|
+
"Always cite the URLs you rely on and prefer official documentation."
|
|
130
|
+
),
|
|
131
|
+
parameters={
|
|
132
|
+
"type": "object",
|
|
133
|
+
"properties": {
|
|
134
|
+
"query": {
|
|
135
|
+
"type": "string",
|
|
136
|
+
"description": "Search query.",
|
|
137
|
+
},
|
|
138
|
+
"count": {
|
|
139
|
+
"type": "integer",
|
|
140
|
+
"description": f"Number of results (1-{MAX_SEARCH_COUNT}, default 5).",
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
"required": ["query"],
|
|
144
|
+
},
|
|
145
|
+
func=_web_search,
|
|
146
|
+
)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""write_file tool: create or overwrite a file in the workspace."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import difflib
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from kiwimatecoder.session import Session
|
|
9
|
+
from kiwimatecoder.tools.base import FunctionTool, ToolResult
|
|
10
|
+
from kiwimatecoder.tools.paths import (
|
|
11
|
+
PathError,
|
|
12
|
+
atomic_write_text,
|
|
13
|
+
display_path,
|
|
14
|
+
resolve_in_workspace,
|
|
15
|
+
workspace_ref,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def preview(args: dict[str, Any], session: Session) -> str:
|
|
20
|
+
"""Return a human-readable preview (diff) for the approval prompt."""
|
|
21
|
+
path = str(args.get("path", "") or "")
|
|
22
|
+
content = str(args.get("content", "") or "")
|
|
23
|
+
try:
|
|
24
|
+
resolved = resolve_in_workspace(path, session.workspace_root)
|
|
25
|
+
except PathError as exc:
|
|
26
|
+
return str(exc)
|
|
27
|
+
rel = display_path(resolved, session.workspace_root)
|
|
28
|
+
if resolved.exists():
|
|
29
|
+
old = resolved.read_text(encoding="utf-8", errors="replace").splitlines(keepends=True)
|
|
30
|
+
new = content.splitlines(keepends=True)
|
|
31
|
+
diff = "".join(
|
|
32
|
+
difflib.unified_diff(old, new, fromfile=rel, tofile=rel, n=3)
|
|
33
|
+
)
|
|
34
|
+
return diff or f"(no changes to {rel})"
|
|
35
|
+
return f"+++ create {rel}\n" + "\n".join(
|
|
36
|
+
f"+{line}" for line in content.splitlines()
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _write_file(args: dict[str, Any], session: Session) -> ToolResult:
|
|
41
|
+
path = str(args.get("path") or "")
|
|
42
|
+
if not path:
|
|
43
|
+
return ToolResult.error("'path' is required")
|
|
44
|
+
content = str(args.get("content", "") or "")
|
|
45
|
+
try:
|
|
46
|
+
resolved = resolve_in_workspace(path, session.workspace_root)
|
|
47
|
+
except PathError as exc:
|
|
48
|
+
return ToolResult.error(str(exc))
|
|
49
|
+
|
|
50
|
+
existed = resolved.exists()
|
|
51
|
+
resolved.parent.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
try:
|
|
53
|
+
atomic_write_text(resolved, content)
|
|
54
|
+
except OSError as exc:
|
|
55
|
+
return ToolResult.error(f"Could not write file: {exc}")
|
|
56
|
+
session.record_touched(workspace_ref(resolved, session.workspace_root))
|
|
57
|
+
verb = "Updated" if existed else "Created"
|
|
58
|
+
return ToolResult(content=f"{verb} {path} ({len(content)} bytes).")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
write_file_tool = FunctionTool(
|
|
62
|
+
name="write_file",
|
|
63
|
+
description=(
|
|
64
|
+
"Create a new file or overwrite an existing one with the given content. "
|
|
65
|
+
"Parent directories are created automatically."
|
|
66
|
+
),
|
|
67
|
+
parameters={
|
|
68
|
+
"type": "object",
|
|
69
|
+
"properties": {
|
|
70
|
+
"path": {
|
|
71
|
+
"type": "string",
|
|
72
|
+
"description": "Path relative to the workspace root.",
|
|
73
|
+
},
|
|
74
|
+
"content": {
|
|
75
|
+
"type": "string",
|
|
76
|
+
"description": "Full content to write to the file.",
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
"required": ["path", "content"],
|
|
80
|
+
},
|
|
81
|
+
func=_write_file,
|
|
82
|
+
writes=True,
|
|
83
|
+
)
|